fix: clarify publisher abuse ban emails (#2697)

This commit is contained in:
Patrick Erichsen
2026-06-16 17:42:17 -07:00
committed by GitHub
parent effd52a4ea
commit 2523832e18
3 changed files with 91 additions and 16 deletions
+32
View File
@@ -96,6 +96,38 @@ describe("moderation notification email copy", () => {
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).not.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("builds restored-account copy that explains tokens stay revoked", async () => {
const email = await buildRestoredAccountEmail({
handle: "restored",
+38 -8
View File
@@ -5,6 +5,15 @@ export const MALICIOUS_REJECTION_ACCOUNT_WARNING =
const MAX_EMAIL_FINDING_SUMMARY_LENGTH = 280;
export const ADMIN_ONE_OFF_TEMPLATE = "generic-one-off";
const PUBLISHER_ABUSE_FINDING_SUMMARY =
"Your account was identified by ClawHub's publisher abuse review workflow for activity that appears inconsistent with our Acceptable Usage policy.";
const PUBLISHER_ABUSE_POLICY_ITEMS = [
"Bulk or spam publishing of large numbers of low-effort, duplicative, placeholder, or machine-generated listings.",
"Publishing large catalogs with little or no usage, maintenance, source clarity, or meaningful differentiation.",
"Artificially inflating installs, downloads, stars, or other engagement metrics.",
"Abnormal download activity with little or no corresponding install activity.",
];
export type NotificationArtifact = {
kind: "skill" | "plugin";
name: string;
@@ -27,6 +36,7 @@ export type BanNotificationEmailContext = {
artifact: NotificationArtifact | null;
scannerLabel: string | null;
findingSummary: string;
policyReasonItems: string[];
};
export type TransactionalEmail = {
@@ -87,6 +97,8 @@ export type AdminOneOffEmailArgs = {
type BanReasonSummary = {
scannerLabel: string | null;
findingSummary: string;
policyReasonItems?: string[];
omitTextAppealUrl?: boolean;
};
function normalizeReasonInput(args: Pick<BanNotificationEmailArgs, "reason" | "trigger">) {
@@ -132,6 +144,15 @@ function summarizeBanReason(args: BanNotificationEmailArgs): BanReasonSummary {
};
}
if (/\bpublisher[_\-\s]?abuse\b/.test(normalized)) {
return {
scannerLabel: null,
findingSummary: PUBLISHER_ABUSE_FINDING_SUMMARY,
policyReasonItems: PUBLISHER_ABUSE_POLICY_ITEMS,
omitTextAppealUrl: true,
};
}
return {
scannerLabel: null,
findingSummary: "ClawHub staff disabled the account after a security review.",
@@ -164,6 +185,7 @@ async function renderAccountSuspendedTemplate(args: {
suspendedAt?: number;
hiddenArtifacts?: number;
findingSummary: string;
policyReasonItems: string[];
preheader: string;
}) {
const { renderAccountSuspendedEmail } = await import("./emailRendering");
@@ -176,6 +198,7 @@ async function renderAccountSuspendedTemplate(args: {
suspendedAt: formatUtcTimestamp(args.suspendedAt, "moderation review"),
...(hiddenArtifacts === undefined ? {} : { hiddenArtifacts }),
findingSummary: args.findingSummary,
policyReasonItems: args.policyReasonItems,
preheader: args.preheader,
});
return rendered.html;
@@ -242,11 +265,19 @@ export async function buildBanNotificationEmail(
): Promise<TransactionalEmail> {
const summary = summarizeBanReason(args);
const artifact = args.artifact ?? null;
const policyReasonItems = summary.policyReasonItems ?? [];
const hiddenArtifacts =
typeof args.hiddenArtifacts === "number" && Number.isFinite(args.hiddenArtifacts)
? Math.max(0, Math.trunc(args.hiddenArtifacts))
: artifact
? 1
: undefined;
const context: BanNotificationEmailContext = {
appealUrl: APPEALS_URL,
artifact,
scannerLabel: summary.scannerLabel,
findingSummary: summary.findingSummary,
policyReasonItems,
};
const lines = [
@@ -256,6 +287,10 @@ export async function buildBanNotificationEmail(
`Reason: ${context.findingSummary}`,
];
if (artifact) lines.push(artifactLabel(artifact));
if (typeof hiddenArtifacts === "number") lines.push(`Artifacts hidden: ${hiddenArtifacts}`);
if (policyReasonItems.length > 0) {
lines.push("", "Policy signals:", ...policyReasonItems.map((item) => `- ${item}`));
}
lines.push(
"",
@@ -263,9 +298,8 @@ export async function buildBanNotificationEmail(
"- Your ClawHub account cannot sign in.",
"- Existing API tokens for the account have been revoked.",
"- Published listings owned by the account may be hidden from public view.",
"",
`Appeal: ${APPEALS_URL}`,
);
if (!summary.omitTextAppealUrl) lines.push("", `Appeal: ${APPEALS_URL}`);
lines.push("", "ClawHub Security");
@@ -276,20 +310,16 @@ export async function buildBanNotificationEmail(
];
const detailLines = [
context.findingSummary,
...policyReasonItems,
...(artifact ? [artifact.name] : []),
...impactItems,
];
const hiddenArtifacts =
typeof args.hiddenArtifacts === "number" && Number.isFinite(args.hiddenArtifacts)
? args.hiddenArtifacts
: artifact
? 1
: undefined;
const html = await renderAccountSuspendedTemplate({
handle: args.handle,
suspendedAt: args.bannedAt,
hiddenArtifacts,
findingSummary: context.findingSummary,
policyReasonItems,
preheader: detailLines.join(" "),
});
+21 -8
View File
@@ -15,6 +15,7 @@ export type AccountSuspendedEmailProps = {
suspendedAt: string;
hiddenArtifacts?: number;
findingSummary: string;
policyReasonItems?: string[];
preheader: string;
};
@@ -23,6 +24,7 @@ export default function AccountSuspendedEmail({
suspendedAt,
hiddenArtifacts,
findingSummary,
policyReasonItems = [],
preheader,
}: AccountSuspendedEmailProps) {
const rows: Array<[string, ReactNode]> = [
@@ -45,7 +47,11 @@ export default function AccountSuspendedEmail({
Your ClawHub account <MonoPill>{handle}</MonoPill> was suspended after moderation review.
</Paragraph>
<Paragraph>{findingSummary}</Paragraph>
<ImpactList
{policyReasonItems.length > 0 ? (
<DetailList title="Policy signals" items={policyReasonItems} />
) : null}
<DetailList
title="What changed"
items={[
"Your ClawHub account cannot sign in.",
"Existing API tokens for the account have been revoked.",
@@ -59,7 +65,7 @@ export default function AccountSuspendedEmail({
);
}
function ImpactList({ items }: { items: string[] }) {
function DetailList({ title, items }: { title: string; items: string[] }) {
return (
<>
<h2
@@ -70,7 +76,7 @@ function ImpactList({ items }: { items: string[] }) {
color: "#f5f5f5",
}}
>
What changed
{title}
</h2>
<ul
style={{
@@ -93,10 +99,17 @@ function ImpactList({ items }: { items: string[] }) {
}
AccountSuspendedEmail.PreviewProps = {
handle: "@octocat",
suspendedAt: "2026-06-11 21:32 UTC",
hiddenArtifacts: 14,
findingSummary: "ClawScan classified the uploaded skill as malicious.",
handle: "@bulkpub",
suspendedAt: "2026-06-16 17:36 UTC",
hiddenArtifacts: 42,
findingSummary:
"Your account was identified by ClawHub's publisher abuse review workflow for activity that appears inconsistent with our Acceptable Usage policy.",
policyReasonItems: [
"Bulk or spam publishing of large numbers of low-effort, duplicative, placeholder, or machine-generated listings.",
"Publishing large catalogs with little or no usage, maintenance, source clarity, or meaningful differentiation.",
"Artificially inflating installs, downloads, stars, or other engagement metrics.",
"Abnormal download activity with little or no corresponding install activity.",
],
preheader:
"Your account has been suspended. Login is blocked, API tokens were revoked, and published artifacts may be hidden.",
"Your account has been suspended after publisher abuse review. Login is blocked, API tokens were revoked, and published artifacts may be hidden.",
} satisfies AccountSuspendedEmailProps;