fix: use no-reply sender for staff emails (#2602)

This commit is contained in:
Patrick Erichsen
2026-06-11 16:58:38 -07:00
committed by GitHub
parent 3b53ddbdbe
commit 2e7f3e752e
4 changed files with 103 additions and 4 deletions
+1
View File
@@ -26,3 +26,4 @@ OPENAI_API_KEY=
RESEND_API_KEY=
CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai
CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai>
CLAWHUB_NOREPLY_FROM=ClawHub <noreply@notifications.openclaw.ai>
+97
View File
@@ -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 <noreply@notifications.openclaw.ai>");
vi.stubEnv("NOREPLY_EMAIL_FROM", "Legacy <legacy@example.com>");
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 <noreply@notifications.openclaw.ai>");
expect(resendBody.from).not.toBe("Legacy <legacy@example.com>");
});
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 <legacy@example.com>");
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 <noreply@notifications.openclaw.ai>");
});
it("set role requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
+3 -4
View File
@@ -12,6 +12,8 @@ import {
toOptionalNumber,
} from "./shared";
const DEFAULT_CLAWHUB_NOREPLY_FROM = "ClawHub <noreply@notifications.openclaw.ai>";
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 <noreply@clawhub.ai>";
const from = process.env.CLAWHUB_NOREPLY_FROM?.trim() || DEFAULT_CLAWHUB_NOREPLY_FROM;
const emailAudit = await runUsersV1MutationRef<{ auditLogId: Id<"auditLogs"> }>(
ctx,
+2
View File
@@ -108,6 +108,8 @@ Ensure Convex env is set (auth + embeddings):
- `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to
`ClawHub Security <noreply@notifications.openclaw.ai>` on the verified Resend
domain
- `CLAWHUB_NOREPLY_FROM` for guarded staff emails, defaulting to
`ClawHub <noreply@notifications.openclaw.ai>` 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