Files
clawhub/convex/lib/emails.test.ts
T
Patrick Erichsen a16ff751bb feat: notify plugin owners only for hard compatibility errors (#3365)
* chore: update plugin inspector to 0.3.20

* feat: gate plugin compatibility emails on hard errors
2026-07-31 17:37:52 -07:00

414 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
APPEALS_URL,
OPENCLAW_DISCORD_URL,
buildAdminOneOffEmail,
buildBanNotificationEmail,
buildMaliciousArtifactEmail,
buildPackageInspectorValidationUrl,
buildPackageInspectorFindingsEmail,
buildPublisherAbuseWarningEmail,
buildRestoredAccountEmail,
buildSecretBlockedPublishEmail,
} from "./emails";
function expectFooterLinksUnderlined(html: string) {
expect(html).toMatch(
/href="https:\/\/clawhub\.ai"[^>]*style="[^"]*color:#8a8a8e[^"]*text-decoration[^"]*underline[^"]*"[^>]*>ClawHub<\/a>/,
);
expect(html).toMatch(
/href="https:\/\/(?:clawhub\.ai\/docs|docs\.openclaw\.ai\/clawhub)"[^>]*style="[^"]*color:#8a8a8e[^"]*text-decoration[^"]*underline[^"]*"[^>]*>Docs<\/a>/,
);
}
describe("moderation notification email copy", () => {
it("builds public-safe malicious skill context with appeal but no local scan guidance", async () => {
const email = await buildBanNotificationEmail({
handle: "gingiris",
source: "autoban",
reason: "malicious.llm_malicious",
artifact: { kind: "skill", name: "gingiris-launch" },
trigger: "scanner.llm.malicious",
});
expect(email.subject).toBe("Your ClawHub account has been suspended");
expect(email.context).toMatchObject({
appealUrl: APPEALS_URL,
artifact: { kind: "skill", name: "gingiris-launch" },
scannerLabel: "ClawScan",
findingSummary: "ClawScan classified the uploaded skill as malicious.",
});
expect(email.text).toContain("Skill: gingiris-launch");
expect(email.text).not.toContain("Scanner:");
expect(email.html).toContain("background-color:#0a0a0b");
expect(email.html).toContain("ClawHub");
expect(email.html).toMatch(
/<p[^>]*>ClawScan classified the uploaded skill as malicious\.<\/p>/,
);
expect(email.html).toMatch(/<li[^>]*>Your ClawHub account cannot sign in\.<\/li>/);
expect(email.html).toMatch(
/<li[^>]*>Existing API tokens for the account have been revoked\.<\/li>/,
);
expect(email.html).toMatch(
/<li[^>]*>Published listings owned by the account may be hidden from public view\.<\/li>/,
);
expect(email.html).not.toContain("<strong>Scanner:</strong>");
expect(email.text).not.toContain("republishing");
expect(email.html).not.toContain("republishing");
expect(email.text).not.toContain("To support your appeal, include scan results");
expect(email.html).not.toContain("Include scan results with your appeal");
expect(email.text).toContain("Appeal: https://appeals.openclaw.ai/");
expect(email.html).not.toContain("If you already appealed");
expect(email.html).not.toContain("separate support email");
expect(email.html).not.toContain("You received this email because");
expect(email.html).toContain("https://docs.openclaw.ai/clawhub");
expectFooterLinksUnderlined(email.html);
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
expect(email.text).not.toContain("https://docs.openclaw.ai/clawhub/cli#scan-path");
});
it("does not leak raw manual moderator notes into outbound email", async () => {
const email = await buildBanNotificationEmail({
handle: "target",
source: "manual",
reason: "internal reviewer note: reporter=user_123 secret finding id=abc",
});
expect(email.context.findingSummary).toBe(
"ClawHub staff disabled the account after a security review.",
);
expect(email.text).not.toContain("internal reviewer note");
expect(email.text).not.toContain("reporter=user_123");
expect(email.html).not.toContain("secret finding id");
});
it("uses rate-limit copy without scan remediation guidance", async () => {
const email = await buildBanNotificationEmail({
handle: "publish-loop",
source: "manual",
reason: "rate limit triggered by automated CLI publishing",
});
expect(email.context).toMatchObject({
scannerLabel: null,
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
});
expect(email.text).toContain("Publishing automation");
expect(email.text).not.toContain("clawhub scan");
expect(email.text).not.toContain("Include scan results");
expect(email.html).not.toContain("Include scan results");
expect(email.html).not.toContain("fixed local copy");
});
it("builds publisher-abuse account-suspended copy from a structured manual reason", async () => {
const email = await buildBanNotificationEmail({
handle: "bulkpub",
source: "manual",
reason: "publisher_abuse: high catalog volume, low installs per skill, abnormal downloads",
hiddenArtifacts: 42,
});
expect(email.subject).toBe("Your ClawHub account has been suspended");
expect(email.context).toMatchObject({
appealUrl: APPEALS_URL,
artifact: null,
scannerLabel: null,
findingSummary:
"Your account was identified by ClawHub's publisher abuse review workflow for activity that appears inconsistent with our Acceptable Usage policy.",
});
expect(email.text).toContain("Hi bulkpub,");
expect(email.text).toContain("Bulk or spam publishing");
expect(email.text).toContain("Artificially inflating installs, downloads, stars");
expect(email.text).toContain(
"Abnormal download activity with little or no corresponding install activity",
);
expect(email.text).toContain("Artifacts hidden");
expect(email.text).not.toContain("publisher_abuse:");
expect(email.text).toContain(`Appeal: ${APPEALS_URL}`);
expect(email.html).toContain("Submit an appeal");
expect(email.html).toContain("Bulk or spam publishing");
expect(email.html).toContain("Artificially inflating installs, downloads, stars");
expect(email.html).toContain("Artifacts hidden");
expect(email.html).not.toContain("publisher_abuse:");
});
it("uses publisher-abuse copy for automated publisher-abuse bans", async () => {
const email = await buildBanNotificationEmail({
handle: "bulkpub",
source: "autoban",
reason: "publisher_abuse: potential ban candidate",
trigger: "publisher_abuse",
});
expect(email.context).toMatchObject({
scannerLabel: null,
findingSummary:
"Your account was identified by ClawHub's publisher abuse review workflow for activity that appears inconsistent with our Acceptable Usage policy.",
});
expect(email.text).toContain("Bulk or spam publishing");
expect(email.text).toContain(`Appeal: ${APPEALS_URL}`);
expect(email.text).not.toContain("ClawHub security checks classified the uploaded skill");
});
it("builds restored-account copy that explains tokens stay revoked", async () => {
const email = await buildRestoredAccountEmail({
handle: "restored",
restoredListings: [
{ kind: "skill", name: "safe-one" },
{ kind: "plugin", name: "@scope/demo" },
],
});
expect(email.subject).toBe("Your ClawHub account has been reinstated");
expect(email.text).toContain("Your ClawHub account can sign in again.");
expect(email.text).toContain("Skill: safe-one");
expect(email.text).toContain("Plugin: @scope/demo");
expect(email.text).toContain("Previously revoked API tokens stay revoked.");
expect(email.html).toContain("ACCOUNT REINSTATED");
expect(email.html).toContain("API tokens issued before the suspension");
expect(email.html).not.toContain("You received this email because");
expect(email.html).toContain("https://docs.openclaw.ai/clawhub");
expectFooterLinksUnderlined(email.html);
});
it("omits restored count rows when batch totals are not complete", async () => {
const email = await buildRestoredAccountEmail({
handle: "restored",
restoredAt: 1_700_000_000_000,
skillsRestored: 5,
packagesRestored: undefined,
});
expect(email.html).not.toContain("Skills restored");
expect(email.html).not.toContain("Packages restored");
expect(email.html).not.toContain(">12<");
expect(email.html).not.toContain(">3<");
expect(email.html).toContain("Your account is active again.");
});
it("builds malicious artifact copy without account appeal language", async () => {
const email = await buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "skill", name: "demo-skill" },
version: "1.2.3",
trigger: "malicious.llm_malicious",
findingSummary: "Attempts to exfiltrate credentials.",
});
expect(email.subject).toBe("ClawHub blocked a skill version");
expect(email.text).toContain("Reason: Attempts to exfiltrate credentials.");
expect(email.html).toContain("Attempts to exfiltrate credentials.");
expect(email.text).toContain("Skill: demo-skill");
expect(email.text).toContain("Version: 1.2.3");
expect(email.text).toContain("clawhub scan download demo-skill --version 1.2.3");
expect(email.text).toContain("Increment the version number before uploading the fixed skill.");
expect(email.text).toContain("https://docs.openclaw.ai/clawhub/moderation");
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
expect(email.text).not.toContain("fixed local copy");
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.html).toContain("Repeated malicious rejections may lead to account disablement");
expect(email.html).toContain("Skill Review");
expect(email.html).not.toContain("Plugin Review");
expect(email.html).toContain("ClawHub blocked a skill version");
expect(email.html).not.toContain("Open ClawHub");
expect(email.html).not.toContain('href="https://clawhub.ai" style="display:inline-block');
expect(email.text).not.toContain(APPEALS_URL);
expect(email.html).not.toContain(APPEALS_URL);
expect(email.html).not.toContain("appeal this decision");
});
it("falls back to generic malicious artifact copy when no ClawScan summary is available", async () => {
const email = await buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "skill", name: "demo-skill" },
version: "1.2.3",
trigger: "malicious.llm_malicious",
});
expect(email.text).toContain("Reason: ClawScan classified the uploaded artifact as malicious.");
});
it("keeps supplied ClawScan summaries to one email-safe line", async () => {
const email = await buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "skill", name: "demo-skill" },
version: "1.2.3",
findingSummary: ` ${"credential exfiltration ".repeat(30)}\nwith hidden tooling `,
});
const reasonLine = email.text.split("\n").find((line) => line.startsWith("Reason: "));
expect(reasonLine).toBeDefined();
expect(reasonLine).not.toContain("\n");
expect(reasonLine?.length).toBeLessThanOrEqual("Reason: ".length + 280);
expect(reasonLine).toContain("...");
});
it("builds plugin scan download copy with an explicit artifact kind", async () => {
const email = await buildMaliciousArtifactEmail({
handle: "publisher",
artifact: { kind: "plugin", name: "@scope/demo" },
version: "2.0.0",
trigger: "malicious.static",
});
expect(email.text).toContain("Plugin: @scope/demo");
expect(email.text).toContain("clawhub scan download @scope/demo --version 2.0.0 --kind plugin");
expect(email.text).toContain("Increment the version number before uploading the fixed plugin.");
});
it("builds secret-blocked publish copy without raw findings", async () => {
const email = await buildSecretBlockedPublishEmail({
handle: "publisher",
artifact: { kind: "skill", name: "secret-skill" },
version: "1.0.0",
});
expect(email.subject).toBe("ClawHub blocked a skill publish");
expect(email.text).toContain("Hi publisher,");
expect(email.text).toContain("TruffleHog found a secret-looking value");
expect(email.text).toContain("Skill: secret-skill");
expect(email.text).toContain("Version: 1.0.0");
expect(email.text).toContain("Rotate the secret if it was real.");
expect(email.text).toContain("Uploaded files for this attempt were deleted");
expect(email.text).not.toContain("sk-local-e2e-redacted-secret-not-real");
expect(email.html).toContain("ClawHub blocked a skill publish");
expect(email.html).toContain("TruffleHog");
expect(email.html).not.toContain("Repeated malicious rejections");
expect(email.html).not.toContain("appeal this decision");
});
it("builds the approved hard-error plugin compatibility email", async () => {
const validationUrl = buildPackageInspectorValidationUrl("@openclaw/demo-plugin");
const email = await buildPackageInspectorFindingsEmail({
handle: "octocat",
packageName: "@openclaw/demo-plugin",
version: "1.0.0",
validationUrl,
});
expect(validationUrl).toBe("https://clawhub.ai/openclaw/plugins/demo-plugin#validation");
expect(email.subject).toBe(
"Update required: @openclaw/demo-plugin will break in an upcoming OpenClaw release",
);
expect(email.text).toBe(
[
"Hi octocat,",
"",
"ClawHub validated @openclaw/demo-plugin@1.0.0 against the upcoming OpenClaw release.",
"",
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
"",
`Review the validation errors: ${validationUrl}`,
"",
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
"",
"Please update the plugin and publish a new version before the next OpenClaw release.",
"",
"—ClawHub",
].join("\n"),
);
expect(email.html).toContain(`href="${validationUrl}"`);
expect(email.html).toContain("Review the validation errors");
expect(email.html).not.toContain("legacy-before-agent-start");
expect(email.html).not.toContain("Email preferences");
});
it("builds publisher abuse warning emails with a deadline and Discord maintainer escalation", async () => {
const email = await buildPublisherAbuseWarningEmail({
handle: "bulkpub",
publisherHandle: "bulkpub",
warningSentAt: Date.UTC(2026, 5, 19, 4, 0, 0),
deadlineAt: Date.UTC(2026, 5, 26, 4, 0, 0),
score: {
modelVersion: "publisher-abuse-pressure.v2",
publishedSkills: 143,
totalInstalls: 2,
totalStars: 0,
totalDownloads: 30,
installsPerSkill: 0.01,
starsPerSkill: 0,
downloadsPerSkill: 0.21,
zScore: 3.2,
reasonCodes: ["high_catalog_volume", "low_installs_per_skill"],
},
});
expect(email.subject).toBe("Action required: ClawHub publisher abuse warning");
expect(email.text).toContain("Hi bulkpub,");
expect(email.text).toContain(
"ClawHub's publisher abuse detection flagged the publisher profile @bulkpub.",
);
expect(email.text).toContain(
"This profile is well outside normal ClawHub publishing patterns for scanned publishers.",
);
expect(email.text).toContain("The biggest signals were:");
expect(email.text).toContain("- Unusually large number of published listings.");
expect(email.text).toContain("- Very low installs per listing.");
expect(email.text).toContain("Deadline: 2026-06-26 04:00:00 UTC");
expect(email.text).toContain("Delete low-quality, duplicate, placeholder");
expect(email.text).toContain(
`For more information, join the OpenClaw Discord and tag one of the maintainers: ${OPENCLAW_DISCORD_URL}`,
);
expect(email.text).not.toContain("Current signals:");
expect(email.text).not.toContain("Published skills");
expect(email.text).not.toContain("Total installs");
expect(email.text).not.toContain("Installs per skill");
expect(email.text).not.toContain("Why this triggered:");
expect(email.text).not.toContain("High Catalog Volume");
expect(email.text).not.toContain("Low Installs Per Skill");
expect(email.html).toContain("Action required: publisher abuse warning");
expect(email.html).toContain("@bulkpub");
expect(email.html).toContain("Very low installs per listing.");
expect(email.html).toContain("2026-06-26 04:00:00 UTC");
expect(email.html).toContain(OPENCLAW_DISCORD_URL);
});
it("builds a templated admin one-off email with escaped staff-authored content", async () => {
const email = await buildAdminOneOffEmail({
recipientHandle: "octocat",
subject: "Content rights report",
title: "Action required: content rights report",
body: "We received a report about <package>. Please reply with context.",
primaryActionLabel: "Open appeal",
primaryActionUrl: "https://appeals.openclaw.ai/case-123",
});
expect(email.subject).toBe("Content rights report");
expect(email.text).not.toContain("Hi octocat,");
expect(email.text).toContain("Action required: content rights report");
expect(email.text.indexOf("Action required: content rights report")).toBeLessThan(
email.text.indexOf("We received a report"),
);
expect(email.text).toContain("Open appeal: https://appeals.openclaw.ai/case-123");
expect(email.html).toContain("font-size:18px");
expect(email.html).toContain("ClawHub");
expect(email.html).toContain("Action required: content rights report");
expect(email.html).not.toContain("Hi octocat");
expect(email.html).toContain("We received a report about &lt;package&gt;.");
expect(email.html).toContain("Open appeal");
expect(email.html).not.toContain("<package>");
expect(email.html).not.toContain("You received this email because");
expect(email.html).toContain("https://docs.openclaw.ai/clawhub");
expectFooterLinksUnderlined(email.html);
});
it("omits the admin one-off button when no action is provided", async () => {
const email = await buildAdminOneOffEmail({
recipientHandle: "octocat",
subject: "Content rights report",
title: "Action required: content rights report",
body: "We received a report about <package>. Please reply with context.",
});
expect(email.text).not.toContain("Open ClawHub:");
expect(email.html).toContain("Action required: content rights report");
expect(email.html).not.toContain("Open ClawHub");
expect(email.html).not.toContain("{{primary_action_label}}");
expect(email.html).not.toContain("{{primary_action_url}}");
expect(email.html).not.toContain('href="https://clawhub.ai" style="display:inline-block');
expect(email.html).not.toContain("You received this email because");
expect(email.html).toContain("https://docs.openclaw.ai/clawhub");
expectFooterLinksUnderlined(email.html);
});
});