diff --git a/docs/COMMENTS.md b/docs/COMMENTS.md
index 8cee7b6f..b36268c9 100644
--- a/docs/COMMENTS.md
+++ b/docs/COMMENTS.md
@@ -25,6 +25,7 @@ WordPress export (contentrain-comments@1) ── import ┘
| `maxDepth` | `4` | Reply nesting cap for **new** public submissions (`0` = flat). Import never clamps |
| `requireEmail` | `true` | Commenters must supply an email; it is never shown publicly |
| `honeypot` | `true` | Hidden `_hp` field; a filled honeypot is silently accepted and dropped |
+| `notifications` | `true` | Email the workspace owner and admins when a comment arrives (`comment-pending` asks for review, `comment-published` when it went live); a mail failure never affects the visitor's response |
| `captcha` | `null` | `'turnstile'` to require a Cloudflare Turnstile token (needs `comments.captcha` + `NUXT_TURNSTILE_SECRET_KEY`) |
| `rateLimitPerIp` | `5` | Submissions per IP per minute on one entry |
| `maxBodyLength` | `5000` | Body length cap for public submissions |
diff --git a/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts b/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts
index 5eb4a47a..18b95292 100644
--- a/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts
+++ b/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts
@@ -15,6 +15,7 @@
import { getClientIp } from '~~/server/utils/form-types'
import { toPublicComment } from '~~/server/utils/comment-thread'
+import { notifyCommentSubmitted } from '~~/server/utils/comment-notifications'
import { normalizeLocaleParam, resolvePublicCommentContext } from '~~/server/utils/comment-public-context'
import { sanitizeString } from '~~/server/utils/sanitize-input'
import { verifyTurnstileToken } from '~~/server/utils/turnstile'
@@ -162,6 +163,23 @@ export default defineEventHandler(async (event) => {
if (!outcome.comment)
throw createError({ statusCode: 500, message: errorMessage('comments.create_failed', { detail: 'empty' }) })
+ // Notify the workspace owner/admins (fire-and-forget) — the model's
+ // `comments.notifications` flag defaults on.
+ if (ctx.config.notifications) {
+ notifyCommentSubmitted({
+ workspaceId: ctx.workspaceId,
+ workspaceName: String(ctx.workspace.name ?? ''),
+ workspaceSlug: String(ctx.workspace.slug ?? ctx.workspaceId),
+ projectId,
+ projectName: ctx.projectName,
+ modelId,
+ entryId,
+ status,
+ authorName,
+ body: text,
+ }).catch(() => {})
+ }
+
// Outbound webhook — gated exactly like forms.webhook_notification (ee).
if (hasFeature(ctx.plan, 'comments.webhook_notification')) {
emitWebhookEvent(projectId, ctx.workspaceId, 'comment.submitted', {
diff --git a/server/utils/comment-notifications.ts b/server/utils/comment-notifications.ts
new file mode 100644
index 00000000..2932325c
--- /dev/null
+++ b/server/utils/comment-notifications.ts
@@ -0,0 +1,45 @@
+import { escapeHtml } from './email-layout'
+
+const EXCERPT_LENGTH = 400
+
+/**
+ * Email the workspace owner + admins about a new public comment. Best-effort
+ * and fire-and-forget: a mail failure never affects the public submit
+ * response. The caller gates it on the model's `comments.notifications`
+ * flag (default on). A pending comment asks for review; an auto-approved one
+ * is already live and says so.
+ */
+export async function notifyCommentSubmitted(input: {
+ workspaceId: string
+ workspaceName: string
+ workspaceSlug: string
+ projectId: string
+ projectName: string
+ modelId: string
+ entryId: string
+ status: 'pending' | 'approved'
+ authorName: string
+ body: string
+}): Promise
{
+ const email = useEmailProvider()
+ if (!email) return
+
+ const db = useDatabaseProvider()
+ const recipients = await db.listWorkspaceNotificationRecipients(input.workspaceId)
+ if (recipients.length === 0) return
+
+ const config = useRuntimeConfig()
+ const excerpt = input.body.length > EXCERPT_LENGTH ? `${input.body.slice(0, EXCERPT_LENGTH).trimEnd()}…` : input.body
+
+ const tpl = emailTemplate(input.status === 'pending' ? 'comment-pending' : 'comment-published', {
+ workspaceName: escapeHtml(input.workspaceName),
+ projectName: escapeHtml(input.projectName),
+ modelName: escapeHtml(input.modelId),
+ entryId: escapeHtml(input.entryId),
+ authorName: escapeHtml(input.authorName),
+ excerptHtml: escapeHtml(excerpt),
+ moderationUrl: `${config.public.siteUrl}/w/${input.workspaceSlug}/projects/${input.projectId}`,
+ })
+
+ await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.body }).catch(() => {})))
+}
diff --git a/server/utils/comment-public-context.ts b/server/utils/comment-public-context.ts
index a98ba49d..f5932001 100644
--- a/server/utils/comment-public-context.ts
+++ b/server/utils/comment-public-context.ts
@@ -15,6 +15,8 @@ export interface PublicCommentContext {
workspaceId: string
plan: ReturnType
workspace: Record
+ /** The project's repository (`owner/repo`), the name an owner knows it by. */
+ projectName: string
modelId: string
config: CommentsConfig
/** The project's default locale (`config.locales.default`), the fallback when a request names none. */
@@ -28,7 +30,7 @@ export async function resolvePublicCommentContext(projectId: string, modelId: st
if (!project)
throw createError({ statusCode: 404, message: errorMessage('comments.not_found') })
- const workspace = await db.getWorkspaceById(project.workspace_id as string, 'id, type, plan, github_installation_id, overage_settings')
+ const workspace = await db.getWorkspaceById(project.workspace_id as string, 'id, name, slug, type, plan, github_installation_id, overage_settings')
if (!workspace)
throw createError({ statusCode: 404, message: errorMessage('comments.not_found') })
@@ -79,6 +81,7 @@ export async function resolvePublicCommentContext(projectId: string, modelId: st
workspaceId: workspace.id as string,
plan,
workspace: workspace as Record,
+ projectName: String(project.repo_full_name),
modelId,
config,
defaultLocale: normalizeLocaleParam(configuredDefault, 'en'),
diff --git a/server/utils/comment-types.ts b/server/utils/comment-types.ts
index 9f0ec6e2..43ad2be5 100644
--- a/server/utils/comment-types.ts
+++ b/server/utils/comment-types.ts
@@ -13,6 +13,8 @@ export interface CommentsConfig {
/** Commenters must supply an email (never shown publicly). */
requireEmail: boolean
honeypot: boolean
+ /** Email the workspace owner and admins when a comment arrives (default true). */
+ notifications: boolean
captcha: 'turnstile' | null
/** Public submissions per IP per minute per entry. */
rateLimitPerIp: number
@@ -26,6 +28,7 @@ export const COMMENTS_CONFIG_DEFAULTS: CommentsConfig = {
maxDepth: 4,
requireEmail: true,
honeypot: true,
+ notifications: true,
captcha: null,
rateLimitPerIp: 5,
maxBodyLength: 5000,
@@ -58,6 +61,7 @@ export function normalizeCommentsConfig(raw: Partial): CommentsC
maxDepth: clampInt(raw.maxDepth, COMMENTS_CONFIG_DEFAULTS.maxDepth, 0, COMMENTS_CONFIG_LIMITS.maxDepth),
requireEmail: raw.requireEmail !== false,
honeypot: raw.honeypot !== false,
+ notifications: raw.notifications !== false,
captcha: raw.captcha === 'turnstile' ? 'turnstile' : null,
rateLimitPerIp: clampInt(raw.rateLimitPerIp, COMMENTS_CONFIG_DEFAULTS.rateLimitPerIp, 1, COMMENTS_CONFIG_LIMITS.rateLimitPerIp),
maxBodyLength: clampInt(raw.maxBodyLength, COMMENTS_CONFIG_DEFAULTS.maxBodyLength, 100, COMMENTS_CONFIG_LIMITS.maxBodyLength),
diff --git a/server/utils/email-layout.ts b/server/utils/email-layout.ts
index 6bb1c239..3d5a3341 100644
--- a/server/utils/email-layout.ts
+++ b/server/utils/email-layout.ts
@@ -84,7 +84,7 @@ export function emailButton(label: string, href: string): string {
return ``
}
-function escapeHtml(value: string): string {
+export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/ {
})
})
+ describe('POST notifies the workspace owner and admins', () => {
+ async function submit(config: Record, comment: { author: string, body: string } = { author: 'Ada', body: 'hello' }) {
+ stubPublicGlobals({ config })
+ const listWorkspaceNotificationRecipients = vi.fn().mockResolvedValue([{ userId: 'u1', email: 'owner@acme.dev' }, { userId: 'u2', email: 'admin@acme.dev' }])
+ const sendEmail = vi.fn().mockResolvedValue(undefined)
+ const createCommentIfAllowed = vi.fn().mockImplementation(async (_ws: string, _limit: number, input: Record) => ({
+ allowed: true,
+ currentCount: 1,
+ comment: { ...approvedRoot, id: '33333333-3333-4333-8333-333333333333', body: input.body, author_name: input.author_name, status: input.status },
+ }))
+ vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue({ sendEmail }))
+ vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record) => ({ subject: `${slug}:${params.projectName}`, body: `${params.authorName}|${params.excerptHtml}|${params.moderationUrl}|${params.entryId}` })))
+ vi.stubGlobal('useRuntimeConfig', () => ({ public: { siteUrl: 'https://studio.test' } }))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
+ getProjectById: vi.fn().mockResolvedValue({ id: PROJECT, workspace_id: WORKSPACE, repo_full_name: 'acme/site', content_root: '.contentrain' }),
+ getWorkspaceById: vi.fn().mockResolvedValue({ id: WORKSPACE, name: 'Acme', slug: 'acme', plan: 'pro', github_installation_id: 42, overage_settings: null }),
+ createCommentIfAllowed,
+ listWorkspaceNotificationRecipients,
+ }))
+ await withTestServer({
+ routes: [{ path: '/api/comments/v1/project-1/posts/entry-1', handler: await loadPublicPost() }],
+ }, async ({ request }) => {
+ const response = await request('/api/comments/v1/project-1/posts/entry-1', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ author: { name: comment.author, email: 'a@b.co' }, body: comment.body }),
+ })
+ expect(response.status).toBe(200)
+ // Fire-and-forget — give it a tick.
+ await new Promise(resolve => setTimeout(resolve, 10))
+ })
+ return { sendEmail, listWorkspaceNotificationRecipients }
+ }
+
+ it('a comment waiting for approval asks for review, escaped, with a link to the project', async () => {
+ const { sendEmail, listWorkspaceNotificationRecipients } = await submit({ requireApproval: true }, { author: 'Ada', body: 'a < b & c' })
+ expect(listWorkspaceNotificationRecipients).toHaveBeenCalledWith(WORKSPACE)
+ expect(sendEmail).toHaveBeenCalledTimes(2)
+ expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'owner@acme.dev', subject: 'comment-pending:acme/site' }))
+ expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'admin@acme.dev' }))
+ expect(sendEmail.mock.calls[0]![0].html).toBe('Ada|a < b & c|https://studio.test/w/acme/projects/project-1|entry-1')
+ })
+
+ it('an auto-approved comment says it is already published', async () => {
+ const { sendEmail } = await submit({ requireApproval: false })
+ expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ subject: 'comment-published:acme/site' }))
+ })
+
+ it('stays quiet when the model turns notifications off', async () => {
+ const { sendEmail, listWorkspaceNotificationRecipients } = await submit({ notifications: false })
+ expect(listWorkspaceNotificationRecipients).not.toHaveBeenCalled()
+ expect(sendEmail).not.toHaveBeenCalled()
+ })
+ })
+
it('POST maps RPC refusals: closed thread → 403, quota → 429, bad parent → field error', async () => {
stubPublicGlobals({ config: { requireApproval: false } })
const outcomes = [
diff --git a/tests/unit/comment-types.test.ts b/tests/unit/comment-types.test.ts
index f48c5882..4bc07225 100644
--- a/tests/unit/comment-types.test.ts
+++ b/tests/unit/comment-types.test.ts
@@ -25,6 +25,11 @@ describe('normalizeCommentsConfig', () => {
expect(normalizeCommentsConfig({ captcha: 'recaptcha' as unknown as 'turnstile' }).captcha).toBeNull()
})
+ it('emails owner and admins unless the model turns notifications off', () => {
+ expect(normalizeCommentsConfig({}).notifications).toBe(true)
+ expect(normalizeCommentsConfig({ notifications: false }).notifications).toBe(false)
+ })
+
it('treats non-numeric values as defaults', () => {
expect(normalizeCommentsConfig({ maxDepth: 'deep' as unknown as number }).maxDepth).toBe(4)
})