From 2e7f3e752e5297a38d71ce171a6e7cd49b71966d Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 11 Jun 2026 16:58:38 -0700 Subject: [PATCH] fix: use no-reply sender for staff emails (#2602) --- .env.local.example | 1 + convex/httpApiV1.handlers.test.ts | 97 +++++++++++++++++++++++++++++++ convex/httpApiV1/usersV1.ts | 7 +-- specs/deploy.md | 2 + 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.env.local.example b/.env.local.example index ced92b93..ddb17226 100644 --- a/.env.local.example +++ b/.env.local.example @@ -26,3 +26,4 @@ OPENAI_API_KEY= RESEND_API_KEY= CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security +CLAWHUB_NOREPLY_FROM=ClawHub diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index d115e3a6..33f795b6 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -6954,6 +6954,103 @@ describe("httpApiV1 handlers", () => { ); }); + it("staff email uses the configured ClawHub noreply sender", async () => { + vi.stubEnv("RESEND_API_KEY", "resend_test"); + vi.stubEnv("CLAWHUB_NOREPLY_FROM", "ClawHub "); + vi.stubEnv("NOREPLY_EMAIL_FROM", "Legacy "); + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn().mockResolvedValueOnce({ + _id: "users:recipient", + handle: "demo", + email: "demo@example.com", + }); + const runMutation = vi + .fn() + .mockResolvedValueOnce(okRate()) + .mockResolvedValueOnce({ + auditLogId: "auditLogs:staff-email", + }) + .mockResolvedValueOnce({ ok: true }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: "email_123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await __handlers.usersPostRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/users/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + userHandle: "demo", + subject: "Notice", + body: "Hello", + confirmUserRequest: true, + confirmUserSignoff: true, + }), + }), + ); + + expect(response.status).toBe(200); + const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(resendBody.from).toBe("ClawHub "); + expect(resendBody.from).not.toBe("Legacy "); + }); + + it("staff email ignores legacy noreply sender env names", async () => { + vi.stubEnv("RESEND_API_KEY", "resend_test"); + vi.stubEnv("CLAWHUB_NOREPLY_FROM", ""); + vi.stubEnv("NOREPLY_EMAIL_FROM", "Legacy "); + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn().mockResolvedValueOnce({ + _id: "users:recipient", + handle: "demo", + email: "demo@example.com", + }); + const runMutation = vi + .fn() + .mockResolvedValueOnce(okRate()) + .mockResolvedValueOnce({ + auditLogId: "auditLogs:staff-email", + }) + .mockResolvedValueOnce({ ok: true }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: "email_123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await __handlers.usersPostRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/users/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + userHandle: "demo", + subject: "Notice", + body: "Hello", + confirmUserRequest: true, + confirmUserSignoff: true, + }), + }), + ); + + expect(response.status).toBe(200); + const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(resendBody.from).toBe("ClawHub "); + }); + it("set role requires auth", async () => { vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); const runMutation = vi.fn().mockResolvedValue(okRate()); diff --git a/convex/httpApiV1/usersV1.ts b/convex/httpApiV1/usersV1.ts index 59effa1a..d09294b5 100644 --- a/convex/httpApiV1/usersV1.ts +++ b/convex/httpApiV1/usersV1.ts @@ -12,6 +12,8 @@ import { toOptionalNumber, } from "./shared"; +const DEFAULT_CLAWHUB_NOREPLY_FROM = "ClawHub "; + const usersV1InternalRefs = internal as unknown as { publishers: { addOfficialPublisherInternal: unknown; @@ -356,10 +358,7 @@ async function handleAdminStaffEmail( const apiKey = process.env.RESEND_API_KEY?.trim(); if (!apiKey) return text("RESEND_API_KEY is not configured", 500, headers); - const from = - process.env.CLAWHUB_NOREPLY_FROM?.trim() || - process.env.NOREPLY_EMAIL_FROM?.trim() || - "ClawHub "; + const from = process.env.CLAWHUB_NOREPLY_FROM?.trim() || DEFAULT_CLAWHUB_NOREPLY_FROM; const emailAudit = await runUsersV1MutationRef<{ auditLogId: Id<"auditLogs"> }>( ctx, diff --git a/specs/deploy.md b/specs/deploy.md index 9c5664c2..4eda0731 100644 --- a/specs/deploy.md +++ b/specs/deploy.md @@ -108,6 +108,8 @@ Ensure Convex env is set (auth + embeddings): - `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to `ClawHub Security ` on the verified Resend domain +- `CLAWHUB_NOREPLY_FROM` for guarded staff emails, defaulting to + `ClawHub ` on the verified Resend domain - `SITE_URL` (your web app URL) - Optional webhook env (see `docs/webhook.md`) - Recommended GitHub App env for authenticated GitHub API reads used by publish