diff --git a/.contentrain/content/system/email-templates/en.json b/.contentrain/content/system/email-templates/en.json index e6cd0b91..edeedaae 100644 --- a/.contentrain/content/system/email-templates/en.json +++ b/.contentrain/content/system/email-templates/en.json @@ -88,5 +88,17 @@ "slug": "usage-overage-started", "subject": "{workspaceName}: {meterName} is now past the plan limit", "body": "

Hi,

{workspaceName} has used all {limit} {meterName} included in the {planName} plan. Overage is on, so nothing has stopped: usage past the limit is billed at {unitPrice} per unit on your next invoice.

{resetLine} You can turn overage off or change the plan in Billing.

Open Billing
" + }, + "comment-pending": { + "body": "

Hi,

A new comment on {entryId} ({modelName}) of {projectName} ({workspaceName}) is waiting for your approval.

{authorName} wrote:

{excerptHtml}

Review comments

", + "name": "Comment — Waiting for Approval", + "slug": "comment-pending", + "subject": "New comment to approve on {projectName}" + }, + "comment-published": { + "body": "

Hi,

A new comment was published on {entryId} ({modelName}) of {projectName} ({workspaceName}).

{authorName} wrote:

{excerptHtml}

Review comments

", + "name": "Comment — Published", + "slug": "comment-published", + "subject": "New comment on {projectName}" } } diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 50f34081..55d677b3 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -264,6 +264,8 @@ "comments.entry": "Entry", "comments.honeypot": "Honeypot Field", "comments.honeypot_description": "Add a hidden field to catch spam bots.", + "comments.notifications": "Email Notifications", + "comments.notifications_description": "Email the workspace owner and admins when a comment arrives.", "comments.import_button": "Upload comments-export.json", "comments.import_description": "Upload the comments-export.json that `contentrain import` wrote next to your content. Re-uploading is safe: comments already imported are skipped.", "comments.import_error": "Comment import failed.", diff --git a/app/components/organisms/CommentsConfigSection.vue b/app/components/organisms/CommentsConfigSection.vue index 0c3ad3f4..742126be 100644 --- a/app/components/organisms/CommentsConfigSection.vue +++ b/app/components/organisms/CommentsConfigSection.vue @@ -16,6 +16,7 @@ interface CommentsConfigShape { maxDepth?: number requireEmail?: boolean honeypot?: boolean + notifications?: boolean captcha?: 'turnstile' | null rateLimitPerIp?: number maxBodyLength?: number @@ -33,6 +34,7 @@ const requireApproval = ref(true) const maxDepth = ref(4) const requireEmail = ref(true) const honeypot = ref(true) +const notifications = ref(true) const captcha = ref<'turnstile' | ''>('') const rateLimitPerIp = ref(5) const maxBodyLength = ref(5000) @@ -44,6 +46,7 @@ function syncFromBrain() { maxDepth.value = cfg?.maxDepth ?? 4 requireEmail.value = cfg?.requireEmail ?? true honeypot.value = cfg?.honeypot ?? true + notifications.value = cfg?.notifications ?? true captcha.value = cfg?.captcha === 'turnstile' ? 'turnstile' : '' rateLimitPerIp.value = cfg?.rateLimitPerIp ?? 5 maxBodyLength.value = cfg?.maxBodyLength ?? 5000 @@ -59,6 +62,7 @@ const hasChanges = computed(() => { || maxDepth.value !== (cfg?.maxDepth ?? 4) || requireEmail.value !== (cfg?.requireEmail ?? true) || honeypot.value !== (cfg?.honeypot ?? true) + || notifications.value !== (cfg?.notifications ?? true) || (captcha.value || null) !== (cfg?.captcha ?? null) || rateLimitPerIp.value !== (cfg?.rateLimitPerIp ?? 5) || maxBodyLength.value !== (cfg?.maxBodyLength ?? 5000) @@ -79,6 +83,7 @@ async function save() { maxDepth: maxDepth.value, requireEmail: requireEmail.value, honeypot: honeypot.value, + notifications: notifications.value, captcha: captcha.value || null, rateLimitPerIp: rateLimitPerIp.value, maxBodyLength: maxBodyLength.value, @@ -289,6 +294,16 @@ async function onImportFile(event: Event) { +
+
+ {{ t('comments.notifications') }} +

+ {{ t('comments.notifications_description') }} +

+
+ +
+
{{ t('comments.captcha') }}
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 `
${escapeHtml(label)}
` } -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) })