fix: remove reply-to from transactional emails (#2725)

This commit is contained in:
Patrick Erichsen
2026-06-17 21:07:25 -07:00
committed by GitHub
parent de28e2a6eb
commit f92ccfd488
5 changed files with 72 additions and 6 deletions
-1
View File
@@ -24,6 +24,5 @@ OPENAI_API_KEY=
# Transactional email # Transactional email
RESEND_API_KEY= RESEND_API_KEY=
CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai
CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai> CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai>
CLAWHUB_NOREPLY_FROM=ClawHub <noreply@notifications.openclaw.ai> CLAWHUB_NOREPLY_FROM=ClawHub <noreply@notifications.openclaw.ai>
+70
View File
@@ -0,0 +1,70 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { resendConstructorMock, resendSendMock } = vi.hoisted(() => ({
resendConstructorMock: vi.fn(function ResendMock() {
return { emails: { send: resendSendMock } };
}),
resendSendMock: vi.fn(),
}));
vi.mock("resend", () => ({
Resend: resendConstructorMock,
}));
const { sendBanNotificationInternal } = await import("./emailsNode");
type SendBanNotificationHandler = {
_handler: (
ctx: unknown,
args: {
userId: string;
bannedAt: number;
to: string;
handle?: string;
source: "manual" | "autoban";
reason?: string;
},
) => Promise<unknown>;
};
describe("transactional account emails", () => {
beforeEach(() => {
vi.stubEnv("RESEND_API_KEY", "resend_test");
resendConstructorMock.mockClear();
resendSendMock.mockReset();
resendSendMock.mockResolvedValue({ data: { id: "email_123" }, error: null });
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("sends ban notifications without a Reply-To header", async () => {
const result = await (
sendBanNotificationInternal as unknown as SendBanNotificationHandler
)._handler(
{},
{
userId: "users:target",
bannedAt: 1_700_000_000_000,
to: "target@example.com",
handle: "target",
source: "manual",
reason: "security review",
},
);
expect(result).toEqual({ ok: true, id: "email_123" });
expect(resendSendMock).toHaveBeenCalledTimes(1);
const [payload, options] = resendSendMock.mock.calls[0] ?? [];
expect(payload).toMatchObject({
from: "ClawHub Security <noreply@notifications.openclaw.ai>",
to: "target@example.com",
subject: "Your ClawHub account has been suspended",
});
expect(payload).not.toHaveProperty("replyTo");
expect(options).toEqual({ idempotencyKey: "ban:users:target:1700000000000" });
});
});
-3
View File
@@ -13,7 +13,6 @@ import {
} from "./lib/emails"; } from "./lib/emails";
const DEFAULT_FROM = "ClawHub Security <noreply@notifications.openclaw.ai>"; const DEFAULT_FROM = "ClawHub Security <noreply@notifications.openclaw.ai>";
const DEFAULT_REPLY_TO = "security@notifications.openclaw.ai";
const notificationArtifactValidator = v.object({ const notificationArtifactValidator = v.object({
kind: v.union(v.literal("skill"), v.literal("plugin")), kind: v.union(v.literal("skill"), v.literal("plugin")),
@@ -32,7 +31,6 @@ function getEmailConfig() {
return { return {
apiKey: process.env.RESEND_API_KEY, apiKey: process.env.RESEND_API_KEY,
from: process.env.CLAWHUB_SECURITY_EMAIL_FROM || DEFAULT_FROM, from: process.env.CLAWHUB_SECURITY_EMAIL_FROM || DEFAULT_FROM,
replyTo: process.env.CLAWHUB_SECURITY_EMAIL || DEFAULT_REPLY_TO,
}; };
} }
@@ -60,7 +58,6 @@ async function sendTransactionalEmail(args: SendEmailArgs) {
{ {
from: config.from, from: config.from,
to: args.to, to: args.to,
replyTo: config.replyTo,
subject: args.subject, subject: args.subject,
text: args.text, text: args.text,
html: args.html, html: args.html,
+2
View File
@@ -8121,6 +8121,7 @@ describe("httpApiV1 handlers", () => {
const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
expect(resendBody.from).toBe("ClawHub <noreply@notifications.openclaw.ai>"); expect(resendBody.from).toBe("ClawHub <noreply@notifications.openclaw.ai>");
expect(resendBody.from).not.toBe("Legacy <legacy@example.com>"); expect(resendBody.from).not.toBe("Legacy <legacy@example.com>");
expect(resendBody).not.toHaveProperty("replyTo");
}); });
it("staff email ignores legacy noreply sender env names", async () => { it("staff email ignores legacy noreply sender env names", async () => {
@@ -8169,6 +8170,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); const resendBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
expect(resendBody.from).toBe("ClawHub <noreply@notifications.openclaw.ai>"); expect(resendBody.from).toBe("ClawHub <noreply@notifications.openclaw.ai>");
expect(resendBody).not.toHaveProperty("replyTo");
}); });
it("set role requires auth", async () => { it("set role requires auth", async () => {
-2
View File
@@ -112,8 +112,6 @@ Ensure Convex env is set (auth + embeddings):
- `JWKS` - `JWKS`
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `RESEND_API_KEY` for account-ban notification email - `RESEND_API_KEY` for account-ban notification email
- `CLAWHUB_SECURITY_EMAIL` for account-action replies, defaulting to
`security@notifications.openclaw.ai`
- `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to - `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to
`ClawHub Security <noreply@notifications.openclaw.ai>` on the verified Resend `ClawHub Security <noreply@notifications.openclaw.ai>` on the verified Resend
domain domain