Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 156 additions & 20 deletions .contentrain/content/system/email-templates/en.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@
"usage_alert.grace_cdn_bandwidth": "Your site is still being delivered: delivery continues up to {percentage}% of the limit, then stops until {date}. Change the plan to keep it running.",
"usage_alert.next_overage": "To keep going now, allow overage or change the plan in Billing.",
"usage_alert.next_upgrade": "To keep going now, change the plan in Billing.",
"usage_alert.resets_on": "The count resets on <strong>{date}</strong>.",
"usage_alert.resets_on": "The count resets on {date}.",
"usage_alert.stopped_ai_messages": "Studio AI answers are paused until {date}. Members using their own AI key are not affected.",
"usage_alert.stopped_api_messages": "Conversation API requests are refused until {date}.",
"usage_alert.stopped_cdn_bandwidth": "Content and media delivery from the CDN has stopped until {date}. Visitors' requests are refused.",
Expand Down
11 changes: 11 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,17 @@
"editor.view_edit": "Edit",
"editor.view_preview": "Preview",
"editor.view_split": "Split",
"email.about_default": "your Contentrain Studio account",
"email.about_line": "This email is about {about}.",
"email.link_github": "GitHub",
"email.link_product": "Studio",
"email.link_website": "Website",
"email.logo_alt": "Contentrain Studio",
"email.privacy": "Privacy",
"email.product_line": "{product} is a Contentrain product.",
"email.product_name": "Studio",
"email.team": "The Contentrain team",
"email.thanks": "Thanks,",
"error.402_description": "Your trial has expired or payment is required. Subscribe to a plan to continue using this workspace.",
"error.402_title": "Payment Required",
"error.403_description": "You don't have permission to access this page.",
Expand Down
15 changes: 15 additions & 0 deletions .contentrain/meta/email-templates/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,20 @@
"source": "import",
"status": "draft",
"updated_by": "contentrain-mcp"
},
"supabase-confirmation": {
"source": "human",
"status": "published",
"updated_by": "contentrain-mcp"
},
"supabase-invite": {
"source": "human",
"status": "published",
"updated_by": "contentrain-mcp"
},
"supabase-magic-link": {
"source": "human",
"status": "published",
"updated_by": "contentrain-mcp"
}
}
42 changes: 37 additions & 5 deletions .contentrain/models/email-templates.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,58 @@
"domain": "system",
"i18n": true,
"title_field": "name",
"description": "Transactional email templates with subject and HTML body. Supports {variable} interpolation at runtime.",
"description": "Transactional email copy. Each entry fills the shared layout (server/utils/email-layout.ts): heading, lead, facts, quote, button, notes. Values are escaped at render time; no HTML.",
"fields": {
"body": {
"description": "Email HTML body — supports {variable} interpolation",
"about": {
"description": "What the email is about, for the footer (\"This email is about …\"); plain text",
"type": "string"
},
"cta_label": {
"description": "Button label",
"type": "string"
},
"cta_url": {
"description": "Button URL, a single {param} that holds an absolute URL",
"type": "string"
},
"facts": {
"description": "Optional key/value rows, one per line as \"Label|value\" — values support {param}, **bold**, `code`",
"type": "text"
},
"heading": {
"description": "Email heading (h1) — supports {param} interpolation",
"required": true,
"type": "richtext"
"type": "string"
},
"lead": {
"description": "Lead paragraph — {param} interpolation, **bold**, `code`, [label]({urlParam})",
"type": "text"
},
"name": {
"description": "Human-readable template name for admin reference",
"required": true,
"type": "string"
},
"notes": {
"description": "Secondary paragraphs below the rule, separated by a blank line — same markup as lead",
"type": "text"
},
"preheader": {
"description": "Hidden inbox preview text — supports {param} interpolation",
"type": "string"
},
"quote": {
"description": "Optional quoted user text, usually a single {param}; rendered as plain escaped text",
"type": "string"
},
"slug": {
"description": "Template identifier used in code (e.g. invite-added)",
"required": true,
"type": "slug",
"unique": true
},
"subject": {
"description": "Email subject line — supports {variable} interpolation",
"description": "Email subject line — supports {param} interpolation (plain text)",
"required": true,
"type": "string"
}
Expand Down
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ NUXT_RESEND_API_KEY=re_your-resend-api-key
RESEND_API_KEY=re_your-resend-api-key
NUXT_EMAIL_SENDER_ADDRESS=noreply@example.com
NUXT_EMAIL_SENDER_NAME=Contentrain Studio
# Where a reply to any Studio email goes (the copy says "reply to this email"). Default: support@contentrain.io.
# NUXT_EMAIL_REPLY_TO=support@example.com
# Footer links of every email (defaults point at contentrain.io).
# NUXT_EMAIL_LINKS_WEBSITE_URL=https://contentrain.io
# NUXT_EMAIL_LINKS_GITHUB_URL=https://github.com/Contentrain
# NUXT_EMAIL_LINKS_PRIVACY_URL=https://contentrain.io/legal/privacy-policy

# -----------------------------------------------------------------------------
# Billing — Polar (default provider)
Expand Down
1 change: 1 addition & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Managed + postgres pair (plain PostgreSQL, no Supabase):
- Set `NUXT_RESEND_API_KEY` (optional on the supabase pair; **required** on the managed pair — magic link + invites)
- Set `NUXT_EMAIL_SENDER_ADDRESS`
- Set `NUXT_EMAIL_SENDER_NAME`
- Optional: `NUXT_EMAIL_REPLY_TO` (default `support@contentrain.io`) and `NUXT_EMAIL_LINKS_*` (footer links). Emails load their logo from `<NUXT_PUBLIC_SITE_URL>/email/logo.png`, so the public URL must be an absolute https address reachable from inbox providers (boot warns otherwise). On the supabase pair the auth emails take the same logo and links from GoTrue's Site URL (`{{ .SiteURL }}`), so set it to the same public https address
- If local Supabase auth SMTP is in use, also set `RESEND_API_KEY`

### AI
Expand Down
3 changes: 2 additions & 1 deletion ee/enterprise/webhook-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ async function sendDlqAlert(
emailProvider.sendEmail({
to: admin.email,
subject: tpl.subject,
html: tpl.body,
html: tpl.html,
text: tpl.text,
}).catch(() => { /* best-effort */ })
}
}
Expand Down
6 changes: 6 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ export default defineNuxtConfig({
},
emailSenderAddress: '', // NUXT_EMAIL_SENDER_ADDRESS
emailSenderName: '', // NUXT_EMAIL_SENDER_NAME
emailReplyTo: 'support@contentrain.io', // NUXT_EMAIL_REPLY_TO — where a reply to any Studio email goes (copy says "reply to this email")
emailLinks: {
websiteUrl: 'https://contentrain.io', // NUXT_EMAIL_LINKS_WEBSITE_URL — footer links of every email
githubUrl: 'https://github.com/Contentrain', // NUXT_EMAIL_LINKS_GITHUB_URL
privacyUrl: 'https://contentrain.io/legal/privacy-policy', // NUXT_EMAIL_LINKS_PRIVACY_URL
},
},

alias: {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"check:strings": "node ./scripts/check-dictionary-keys.mjs",
"email:previews": "EMAIL_PREVIEW_DIR=${EMAIL_PREVIEW_DIR:-.tmp/email-previews} vitest run --project unit tests/unit/email-templates.test.ts -t previews",
"email:supabase": "EMAIL_SUPABASE_WRITE=1 vitest run --project unit tests/unit/email-templates.test.ts -t \"supabase auth templates\"",
"test": "vitest --project unit --project integration --project nuxt",
"test:watch": "vitest --project unit --project integration --project nuxt",
"test:unit": "vitest run --project unit",
Expand Down
Binary file added public/email/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion server/api/billing/webhook/[provider].post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ async function sendBillingEmail(
await email.sendEmail({
to: user.email,
subject: tpl.subject,
html: tpl.body,
html: tpl.html,
text: tpl.text,
}).catch((err) => {
// eslint-disable-next-line no-console -- surface delivery failures without aborting the webhook
console.error('[billing-webhook] Failed to send', templateSlug, 'email:', err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export default defineEventHandler(async (event) => {
await emailProvider.sendEmail({
to: invitedEmail,
subject: tpl.subject,
html: tpl.body,
html: tpl.html,
text: tpl.text,
})
emailSent = true
}
Expand Down
11 changes: 11 additions & 0 deletions server/plugins/00.validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ export default defineNitroPlugin(() => {
warnings.push('NUXT_OAUTH_GITHUB_CLIENT_ID differs from NUXT_GITHUB_CLIENT_ID — GitHub login must use the GitHub App\'s client credentials, otherwise installing or connecting the App fails with 403')
}

// Emails — every Studio email links to and loads its logo from public.siteUrl (`server/utils/email-layout.ts`). Without an
// absolute address an email's button is left out (no dead relative link) and its logo breaks; over http in production
// inbox providers refuse the logo. Said at boot, once, rather than an email at a time.
if (config.resend?.apiKey) {
const emailSiteUrl = String((config.public as { siteUrl?: string } | undefined)?.siteUrl ?? '').trim()
if (!/^https?:\/\/[^/\s]+/i.test(emailSiteUrl))
warnings.push('NUXT_PUBLIC_SITE_URL is not an absolute URL — emails go out without their button and with a broken logo')
else if (process.env.NODE_ENV === 'production' && !emailSiteUrl.startsWith('https://'))
warnings.push('NUXT_PUBLIC_SITE_URL is not https — email logos and links are served over http (inboxes may block the logo)')
}

// Anthropic — optional (BYOA fallback available)
if (!config.anthropic.apiKey)
warnings.push('NUXT_ANTHROPIC_API_KEY is not set — chat requires user BYOA keys')
Expand Down
3 changes: 2 additions & 1 deletion server/plugins/trial-reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ async function runTrialReminders(): Promise<void> {
await email.sendEmail({
to: user.email,
subject: tpl.subject,
html: tpl.body,
html: tpl.html,
text: tpl.text,
})
await db.setTrialReminderStage(ws.id as string, window.stage)
}
Expand Down
11 changes: 8 additions & 3 deletions server/providers/email.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* Provider-agnostic email interface.
*
* Used for application-level emails (workspace invite notifications,
* resend invites). Auth emails (magic link, invite confirmation) are
* handled by Supabase's built-in SMTP integration.
* Used for every application-level email (invites, billing, usage alerts,
* form and comment notifications, webhook alerts, managed-auth magic links).
* Markup comes from the shared layout (`server/utils/email-layout.ts`).
* With the Supabase auth provider, magic-link and invite emails are sent by
* Supabase's own SMTP using `supabase/templates/*.html`, which are generated
* from the same layout (`pnpm email:templates`).
*
* Current impl: Resend (server/providers/resend-email.ts)
* Future impls: SendGrid, Postmark, nodemailer (on-premise SMTP)
Expand All @@ -13,6 +16,8 @@ export interface EmailSendOptions {
to: string
subject: string
html: string
/** Plain-text alternative. Every Studio email carries one. */
text?: string
from?: string
}

Expand Down
4 changes: 2 additions & 2 deletions server/providers/managed-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ export function createManagedAuthProvider(): AuthProvider {

// Copy lives in the email-templates content model (auth-magic-link).
const tpl = emailTemplate('auth-magic-link', { url })
await emailProvider.sendEmail({ to: email_, subject: tpl.subject, html: tpl.body })
await emailProvider.sendEmail({ to: email_, subject: tpl.subject, html: tpl.html, text: tpl.text })
},

async inviteUserByEmail(email, options) {
Expand All @@ -566,7 +566,7 @@ export function createManagedAuthProvider(): AuthProvider {

// Copy lives in the email-templates content model (auth-invite).
const tpl = emailTemplate('auth-invite', { url })
await emailProvider.sendEmail({ to: row.email, subject: tpl.subject, html: tpl.body })
await emailProvider.sendEmail({ to: row.email, subject: tpl.subject, html: tpl.html, text: tpl.text })

return { userId: row.id }
},
Expand Down
3 changes: 3 additions & 0 deletions server/providers/resend-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function createResendEmailProvider(apiKey: string): EmailProvider {
const senderEmail = (config.emailSenderAddress as string) || 'noreply@contentrain.io'
const senderName = (config.emailSenderName as string) || 'Contentrain Studio'
const defaultFrom = `${senderName} <${senderEmail}>`
const replyTo = ((config.emailReplyTo as string) || '').trim()

return {
async sendEmail(options) {
Expand All @@ -21,6 +22,8 @@ export function createResendEmailProvider(apiKey: string): EmailProvider {
to: options.to,
subject: options.subject,
html: options.html,
...(options.text ? { text: options.text } : {}),
...(replyTo ? { replyTo } : {}),
})
},
}
Expand Down
16 changes: 7 additions & 9 deletions server/utils/comment-notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { escapeHtml } from './email-layout'

const EXCERPT_LENGTH = 400

/**
Expand Down Expand Up @@ -32,14 +30,14 @@ export async function notifyCommentSubmitted(input: {
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),
workspaceName: input.workspaceName,
projectName: input.projectName,
modelName: input.modelId,
entryId: input.entryId,
authorName: input.authorName,
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(() => {})))
await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.html, text: tpl.text }).catch(() => {})))
}
Loading
Loading